分析器

  这里是该计算器所接受的语言的语法:

    program:
        END                         // END表示输入结束
        expr_list END
    expr_list:
        expression PRINT            // PRINT是分号
        expression PRINT expr_list
    expression:
        expression + term
        expression - term
        term
    term:
        term / primary
        term * primary
        primary
    primary:
        NUMBER
        NAME
        NAME = expression
        - primary
        (expression)

换句话说,一个程序是由分号分隔的一系列表达式。表达式的基本单元是数、名字和运算符*、/、+、-(包括一元的和二元的)和=。名字在使用之前无需声明。

  语法分析采用通常称为递归下降的风格,这是一种很流行的直截了当的自顶向下技术。在像C++这类语言中,函数调用的代价相对比较低,采用这种技术就相当有效。对于语法中的每个产生式存在着一个函数,它还要调用其他的函数。终结符(例如END、NUMBER、+和-)由词法分析程序get_token()识别,而非终结符则由语法分析函数expr()、term()和prim()识别。一旦一个(子)表达式的两个运算对象都已经知道了,就立即对这个表达式求值。而对于真正的编译器,在这一点则可能生成代码。

  分析器利用函数get_token()取得输入,最后一次调用get_token()得到的值可以在全局变量curr_tok里找到。curr_tok的类型是枚举Token_value:

    enum Token_value
    {
        NAME,        NUMBER,        END,
        PLUS='+',    MINUS='-',     MUL='*',        DIV='/',
        PRINT=';',   ASSIGN='=',    LP='(',         RP=')'
    };
    Token_value cur_tok = PRINT;

将单词(token)用它们的字符所对应的整数表示,这样做既方便有效,又能帮助使用排错系统的人。只要在所有输入字符中,没有任何字符的值被当做枚举符使用,这种方式就可以工作---在我所知道的字符集中,都不存在可打印字符的值只有一位数字的情况。我选择PRINT作为curr_tok的初始值,因为这正是计算器计算完一个表达式,并显示结果值之后curr_tok应该具有的值。这样,我也就是以一种正常状态“启动这个系统”,这样做最大限度地减少了出错的机会和对特殊启动代码的需求。

  每个分析函数都有一个bool(4.2节)参数,指明该函数是否需要调用get_token()去取得下一个单词。每个分析函数都将对“它的”表达式求值并返回这个值。函数expr()处理加和减。它由一个查找被加减的项的循环组成:

    double expr(bool get)        // 加和减
    {
        double left = term(get);
        for(;;)                  // “永远”
            switch(curr_tok)
            {
            case PLUS:
                left += term(true);
                break;
            case MINUS:
                left -= term(true);
                break;
            default:
                return left;
            }
    }

这个函数本身并不做多少事情,它以一个大程序里高层函数的典型方式,调用其他函数去完成具体工作。

  switch-statement对照着一集常量,检查在关键字switch之后的括号里所描述的条件值。break语句用于从开关语句中退出。在各个case之后的常量必须互不相同。如果被检查的值与任何case标号都不匹配,那么就选择default。程序员不一定要提供default。

  请注意,形如2-3+4这样的表达式将被按(2-3)+4的方式求值,正也是语法所描述的。

  奇特的记法形式for(;;)是人们描述无限循环的一种标准方式,你可以将它读作“永远”。这是for-statement(6.3.3节)的一种退化形式;我们也可以用while(true)代替它。这里的开关语句将反复执行,直到遇到的不是+和-,这就会导致在default情况中的返回语句的执行。

  运算符+=和-=分别用于处理加和减;可以用left=left+term(true)和left=left-term(true)代替它们而不会影响程序的意义。然而,left+=term(true)和left-=term(true)不仅更短一些,而且也更直接地描述了我们想做的事情。每个赋值运算符是一个单独的词法单词,因此,a+ =1;十个语法错误,因为在+和=之间出现了空格。

  下面这些二元运算符都有相应的赋值运算符:

+    -    *    /    %    &    |    ^    <<    >>

所以下面赋值运算符都可以使用

=    +=    -=    *=    /=    %=    &=    |=    ^=    <<=    >>=

这里的%是取模,或说是求余运算符;&、|和^分别是按位逻辑“与”、按位逻辑“或”和按位逻辑“异或”运算符;<< 和 >> 是左移和右移运算符。6.2节总结了所有运算符和它们的意义。对于一个应用于内部类型运算对象的二元运算符@,表达式x@=y的意思就是x=x@y,不过在前一种情况中只对x求值一次。

  第8章和第9章将讨论如何将程序组织为一组模块。除了一个例外之处,这个计算器例子中的所有声明都可以排列好,使每个东西都只需要在它被使用之前声明一次。这个例子就是expr(),它调用term(),这个函数转而调用prim(),而prim()又调用了expr()。这种循环调用的情况必须打破,只要将一个声明

double expr(bool);

放在prim()的定义之前就行了。

  函数term()处理乘和除,采用的方式与expr()处理加和减的方式一样:

    double term(bool get)        // 乘和除
    {
        double left = prim(get);
        for(;;)
            switch(curr_tok)
            {
            case MUL:
                left *= prim(true);
                break;
            case DIV:
                if(double d = prim(true))
                {
                    left /= d;
                    break;
                }
                return error("divie by 0");
            default:
                return left;
            }
    }

用0去除的结果无定义,通常将是个灾难。因此我们需要在除之前检查是否为0,如果检查到以0作为除数就调用error()。函数error()将在6.1.4节讨论。

  变量d正是在需要它的地方才被引进程序里,而且立即做了初始化。一个在条件中引进的名字,其作用域就是这个条件所控制的语句,其结果值被作为这个条件的值(6.3.2.1节)。这样,只有在d不是0的情况下除法和赋值left/=d才会进行。

函数prim()处理初等项的方式很像expr()和term(),当然,因为我们需要进入调用层次的更下一层,在这里需要多做一点事情,而且不必做循环:

    double number_value;
    string string_value;

    double prim(bool get)            // 处理初等项
    {
        if(get) get_token();

        switch(curr_tok)
        {
        case NUMBER:                 // 浮点常量
        {
            double v = number_value;
            get_token();
            return v;        
        }
        case NAME:
        {
            double& v = table[string_value];
            if(get_token()==ASSIGN) v = expr(true);
            return v;
        }
        case MINUS:                    // 一元
            return -prim(true);
        case LP:
        {
            double e = expr(true);
            if(curr_tok != RP) return error(") expected");
            get_token();                // 吃掉 ')'
            return e;
        }
        default:
            return error(primary expected);
        }
    }

如果遇到一个NUMBER(也就是说,遇到一个整数或浮点的文字量),prim()就返回它的值。输入例程get_token()将把有关的值存入全局变量number_value。在程序里采用全局变量,一般说明这种结构不是很清晰---应该进行某种优化。目前就出现了这种情况。理想方式是让一个词法单词由两个部分组成:一个刻画单词种类(在这个程序里是Token_value)的值和(如果需要的话)一个单词值。在这里只存在一个单独的变量curr_tok,所以需要用一个全局变量number_value保存最近读到的一个NUMBER值。清除这个不良的全局变量的工作留做练习(6.6[21])。在调用get_token()之前,我们并不需要将number_value的值保存到某个局部变量v里,因为对于合法的输入,该计算器在去读其他形式的输入之前只需要用一个数。当然,将这种值存起来并在出错时显示,也能对用户有所帮助。

  按照将最近的一个NUMBER的值保存于number_value的同样方式,代表最近遇到的NAME的字符串将被保存在string_value里。在对一个名字做其他事情之前,计算器必须首先去看是要对它赋值,还是要去读它的值。这两种情况都需要检查符号表,该符号表是一个map(3.7.4节、17.4.1节):

map<string, double> table;

也就是说,如果用一个string去索引table,得到的结果是对应于该string的double值。例如,如果用户输入

radius = 6338.388;

计算器将执行

double& v = talbe["radius"];
// ...expr()计算要赋的值...
v = 6378.388;

在expr()由输入字符序列计算6378.388的值的过程中,将通过引用v去把握住与radius相关联的那个double值。

🔚